VERSION = "1.0.0"

local micro = import("micro")
local runtime = import("runtime")
local filepath = import("path/filepath")
local shell = import("micro/shell")
local buffer = import("micro/buffer")
local config = import("micro/config")
local util = import("micro/util")
local os = import("os")

local linters = {}

-- creates a linter entry, call from within an initialization function, not
-- directly at initial load time
--
-- name: name of the linter
-- filetype: filetype to check for to use linter
-- cmd: main linter process that is executed
-- args: arguments to pass to the linter process
--     use %f to refer to the current file name
--     use %d to refer to the current directory name
-- errorformat: how to parse the linter/compiler process output
--     %f: file, %l: line number, %m: error/warning message
-- os: list of OSs this linter is supported or unsupported on
--     optional param, default: {}
-- whitelist: should the OS list be a blacklist (do not run the linter for these OSs)
--            or a whitelist (only run the linter for these OSs)
--     optional param, default: false (should blacklist)
-- domatch: should the filetype be interpreted as a lua pattern to match with
--          the actual filetype, or should the linter only activate on an exact match
--     optional param, default: false (require exact match)
-- loffset: line offset will be added to the line number returned by the linter
--          useful if the linter returns 0-indexed lines
--     optional param, default: 0
-- coffset: column offset will be added to the col number returned by the linter
--          useful if the linter returns 0-indexed columns
--     optional param, default: 0
-- callback: function to call before executing the linter, if it returns
--           false the lint is canceled. The callback is passed the buf.
--     optional param, default: nil
function makeLinter(name, filetype, cmd, args, errorformat, os, whitelist, domatch, loffset, coffset, callback)
    if linters[name] == nil then
        linters[name] = {}
        linters[name].filetype = filetype
        linters[name].cmd = cmd
        linters[name].args = args
        linters[name].errorformat = errorformat
        linters[name].os = os or {}
        linters[name].whitelist = whitelist or false
        linters[name].domatch = domatch or false
        linters[name].loffset = loffset or 0
        linters[name].coffset = coffset or 0
        linters[name].callback = callback or nil
    end
end

function removeLinter(name)
    linters[name] = nil
end

function preinit()
    local devnull = "/dev/null"
    if runtime.GOOS == "windows" then
        devnull = "NUL"
    end

    makeLinter("gcc", "c", "gcc", {"-fsyntax-only", "-Wall", "-Wextra", "%f"}, "%f:%l:%c:.+: %m")
    makeLinter("g++", "c++", "gcc", {"-fsyntax-only","-std=c++14", "-Wall", "-Wextra", "%f"}, "%f:%l:%c:.+: %m")
    makeLinter("dmd", "d", "dmd", {"-color=off", "-o-", "-w", "-wi", "-c", "%f"}, "%f%(%l%):.+: %m")
    makeLinter("eslint", "javascript", "eslint", {"-f","compact","%f"}, "%f: line %l, col %c, %m")
    makeLinter("gobuild", "go", "go", {"build", "-o", devnull, "%d"}, "%f:%l:%c:? %m")
    -- makeLinter("golint", "go", "golint", {"%f"}, "%f:%l:%c: %m")
    makeLinter("hlint", "haskell", "hlint", {"%f"}, "%f:%(?%l[,:]%c%)?.-: %m")
    makeLinter("javac", "java", "javac", {"-d", "%d", "%f"}, "%f:%l: error: %m")
    makeLinter("jshint", "javascript", "jshint", {"%f"}, "%f: line %l,.+, %m")
    makeLinter("literate", "literate", "lit", {"-c", "%f"}, "%f:%l:%m", {}, false, true)
    makeLinter("luacheck", "lua", "luacheck", {"--no-color", "%f"}, "%f:%l:%c: %m")
    makeLinter("nim", "nim", "nim", {"check", "--listFullPaths", "--stdout", "--hints:off", "%f"}, "%f.%l, %c. %m")
    makeLinter("clang", "objective-c", "xcrun", {"clang", "-fsyntax-only", "-Wall", "-Wextra", "%f"}, "%f:%l:%c:.+: %m")
    makeLinter("pyflakes", "python", "pyflakes", {"%f"}, "%f:%l:.-:? %m")
    makeLinter("mypy", "python", "mypy", {"%f"}, "%f:%l: %m")
    makeLinter("pylint", "python", "pylint", {"--output-format=parseable", "--reports=no", "%f"}, "%f:%l: %m")
    makeLinter("flake8", "python", "flake8", {"%f"}, "%f:%l:%c: %m")
    makeLinter("shfmt", "shell", "shfmt", {"%f"}, "%f:%l:%c: %m")
    makeLinter("swiftc", "swift", "xcrun", {"swiftc", "%f"}, "%f:%l:%c:.+: %m", {"darwin"}, true)
    makeLinter("swiftc", "swift", "swiftc", {"%f"}, "%f:%l:%c:.+: %m", {"linux"}, true)
    makeLinter("yaml", "yaml", "yamllint", {"--format", "parsable", "%f"}, "%f:%l:%c:.+ %m")

    config.MakeCommand("lint", function(bp, args)
        bp:Save()
        runLinter(bp.Buf)
    end, config.NoComplete)

    config.AddRuntimeFile("linter", config.RTHelp, "help/linter.md")
end

function contains(list, element)
    for k, v in pairs(list) do
        if v == element then
            return true
        end
    end
    return false
end

function runLinter(buf)
    local ft = buf:FileType()
    local file = buf.Path
    local dir = "." .. util.RuneStr(os.PathSeparator) .. filepath.Dir(file)

    for k, v in pairs(linters) do
        local ftmatch = ft == v.filetype
        if v.domatch then
            ftmatch = string.match(ft, v.filetype)
        end

        local hasOS = contains(v.os, runtime.GOOS)
        if not hasOS and v.whitelist then
            ftmatch = false
        end
        if hasOS and not v.whitelist then
            ftmatch = false
        end

        local args = {}
        for k, arg in pairs(v.args) do
            args[k] = arg:gsub("%%f", file):gsub("%%d", dir)
        end

        if ftmatch then
            lint(buf, k, v.cmd, args, v.errorformat, v.loffset, v.coffset, v.callback)
        end
    end
end

function onSave(bp)
    runLinter(bp.Buf)
    return true
end

function lint(buf, linter, cmd, args, errorformat, loff, coff, callback)
    buf:ClearMessages(linter)

    if callback ~= nil then
        if not callback(buf) then
            return
        end
    end

    shell.JobSpawn(cmd, args, nil, nil, onExit, buf, linter, errorformat, loff, coff)
end

function onExit(output, args)
    local buf, linter, errorformat, loff, coff = args[1], args[2], args[3], args[4], args[5]
    local lines = split(output, "\n")

    local regex = errorformat:gsub("%%f", "(..-)"):gsub("%%l", "(%d+)"):gsub("%%c", "(%d+)"):gsub("%%m", "(.+)")
    for _,line in ipairs(lines) do
        -- Trim whitespace
        line = line:match("^%s*(.+)%s*$")
        if string.find(line, regex) then
            local file, line, col, msg = string.match(line, regex)
            local hascol = true
            if not string.find(errorformat, "%%c") then
                hascol = false
                msg = col
            elseif col == nil then
                hascol = false
            end
            if basename(buf.Path) == basename(file) then
                local bmsg = nil
                if hascol then
                    local mstart = buffer.Loc(tonumber(col-1+coff), tonumber(line-1+loff))
                    local mend = buffer.Loc(tonumber(col+coff), tonumber(line-1+loff))
                    bmsg = buffer.NewMessage(linter, msg, mstart, mend, buffer.MTError)
                else
                    bmsg = buffer.NewMessageAtLine(linter, msg, tonumber(line+loff), buffer.MTError)
                end
                buf:AddMessage(bmsg)
            end
        end
    end
end

function split(str, sep)
    local result = {}
    local regex = ("([^%s]+)"):format(sep)
    for each in str:gmatch(regex) do
        table.insert(result, each)
    end
    return result
end

function basename(file)
    local sep = "/"
    if runtime.GOOS == "windows" then
        sep = "\\"
    end
    local name = string.gsub(file, "(.*" .. sep .. ")(.*)", "%2")
    return name
end
